diff --git a/native/calendar/loading-indicator.react.js b/native/calendar/loading-indicator.react.js index 52e4f7212..730588b3d 100644 --- a/native/calendar/loading-indicator.react.js +++ b/native/calendar/loading-indicator.react.js @@ -1,34 +1,34 @@ // @flow import * as React from 'react'; import { ActivityIndicator, StyleSheet, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import type { LoadingStatus } from 'lib/types/loading-types'; type Props = { - loadingStatus: LoadingStatus, - color: string, - canUseRed: boolean, + +loadingStatus: LoadingStatus, + +color: string, + +canUseRed: boolean, }; function LoadingIndicator(props: Props): React.Node { if (props.loadingStatus === 'error') { const colorStyle = props.canUseRed ? { color: 'red' } : { color: props.color }; return ; } else if (props.loadingStatus === 'loading') { return ; } else { return null; } } const styles = StyleSheet.create({ errorIcon: { fontSize: 16, paddingTop: Platform.OS === 'android' ? 6 : 4, }, }); export default LoadingIndicator; diff --git a/native/calendar/thread-picker-modal.react.js b/native/calendar/thread-picker-modal.react.js index ba07f27db..90e482b34 100644 --- a/native/calendar/thread-picker-modal.react.js +++ b/native/calendar/thread-picker-modal.react.js @@ -1,99 +1,99 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import { useDispatch } from 'react-redux'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions'; import { threadSearchIndex } from 'lib/selectors/nav-selectors'; import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; import Modal from '../components/modal.react'; import ThreadList from '../components/thread-list.react'; import { RootNavigatorContext } from '../navigation/root-navigator-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { waitForInteractions } from '../utils/timers'; export type ThreadPickerModalParams = { presentedFrom: string, dateString: string, }; type Props = { - navigation: RootNavigationProp<'ThreadPickerModal'>, - route: NavigationRoute<'ThreadPickerModal'>, + +navigation: RootNavigationProp<'ThreadPickerModal'>, + +route: NavigationRoute<'ThreadPickerModal'>, }; function ThreadPickerModal(props: Props): React.Node { const { navigation, route: { params: { dateString }, }, } = props; const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector(state => state.nextLocalID); const dispatch = useDispatch(); const rootNavigatorContext = React.useContext(RootNavigatorContext); const threadPicked = React.useCallback( (threadID: string) => { invariant( dateString && viewerID && rootNavigatorContext, 'inputs to threadPicked should be set', ); rootNavigatorContext.setKeyboardHandlingEnabled(false); dispatch({ type: createLocalEntryActionType, payload: createLocalEntry(threadID, nextLocalID, dateString, viewerID), }); }, [rootNavigatorContext, dispatch, viewerID, nextLocalID, dateString], ); React.useEffect( () => navigation.addListener('blur', async () => { await waitForInteractions(); invariant( rootNavigatorContext, 'RootNavigatorContext should be set in onScreenBlur', ); rootNavigatorContext.setKeyboardHandlingEnabled(true); }), [navigation, rootNavigatorContext], ); const index = useSelector(state => threadSearchIndex(state)); const onScreenThreadInfos = useSelector(state => onScreenEntryEditableThreadInfos(state), ); return ( ); } const styles = StyleSheet.create({ threadListItem: { paddingLeft: 10, paddingRight: 10, paddingVertical: 2, }, }); export default ThreadPickerModal; diff --git a/native/chat/new-messages-pill.react.js b/native/chat/new-messages-pill.react.js index d97f0afc5..ef3f7cd51 100644 --- a/native/chat/new-messages-pill.react.js +++ b/native/chat/new-messages-pill.react.js @@ -1,73 +1,73 @@ // @flow import * as React from 'react'; import { TouchableOpacity, View, Text, Platform, Animated } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useStyles } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; type Props = { - onPress: () => mixed, - newMessageCount: number, - containerStyle?: ViewStyle, - style?: ViewStyle, + +onPress: () => mixed, + +newMessageCount: number, + +containerStyle?: ViewStyle, + +style?: ViewStyle, ...React.ElementConfig, }; function NewMessagesPill(props: Props): React.Node { const { onPress, newMessageCount, containerStyle, style, ...containerProps } = props; const styles = useStyles(unboundStyles); return ( {newMessageCount} ); } const unboundStyles = { countBubble: { alignItems: 'center', backgroundColor: 'vibrantGreenButton', borderRadius: 25, height: 25, justifyContent: 'center', paddingBottom: Platform.OS === 'android' ? 2 : 0, paddingLeft: 1, position: 'absolute', right: -8, top: -8, width: 25, }, countText: { color: 'white', textAlign: 'center', }, button: { backgroundColor: 'floatingButtonBackground', borderColor: 'floatingButtonLabel', borderRadius: 30, borderWidth: 4, paddingHorizontal: 12, paddingVertical: 6, }, icon: { color: 'floatingButtonLabel', fontSize: 32, fontWeight: 'bold', }, }; export default NewMessagesPill; diff --git a/native/components/clearable-text-input.react.ios.js b/native/components/clearable-text-input.react.ios.js index 085c85eeb..ac127a6bd 100644 --- a/native/components/clearable-text-input.react.ios.js +++ b/native/components/clearable-text-input.react.ios.js @@ -1,189 +1,189 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TextInput as BaseTextInput, View, StyleSheet } from 'react-native'; import type { KeyPressEvent } from '../types/react-native'; import type { ClearableTextInputProps } from './clearable-text-input'; import TextInput from './text-input.react'; type State = { - textInputKey: number, + +textInputKey: number, }; class ClearableTextInput extends React.PureComponent< ClearableTextInputProps, State, > { state: State = { textInputKey: 0, }; pendingMessage: ?{ value: string, resolve: (value: string) => void }; lastKeyPressed: ?string; lastTextInputSent: number = -1; currentTextInput: ?React.ElementRef; focused: boolean = false; sendMessage() { if (this.pendingMessageSent) { return; } const { pendingMessage } = this; invariant(pendingMessage, 'cannot send an empty message'); pendingMessage.resolve(pendingMessage.value); const textInputSent = this.state.textInputKey - 1; if (textInputSent > this.lastTextInputSent) { this.lastTextInputSent = textInputSent; } } get pendingMessageSent(): boolean { return this.lastTextInputSent >= this.state.textInputKey - 1; } onOldInputChangeText: (text: string) => void = text => { const { pendingMessage, lastKeyPressed } = this; invariant( pendingMessage, 'onOldInputChangeText should have a pendingMessage', ); if ( !this.pendingMessageSent && lastKeyPressed && lastKeyPressed.length > 1 ) { // This represents an autocorrect event on blur pendingMessage.value = text; } this.lastKeyPressed = null; this.sendMessage(); this.updateTextFromOldInput(text); }; updateTextFromOldInput(text: string) { const { pendingMessage } = this; invariant( pendingMessage, 'updateTextFromOldInput should have a pendingMessage', ); const pendingValue = pendingMessage.value; if (!pendingValue || !text.startsWith(pendingValue)) { return; } const newValue = text.substring(pendingValue.length); if (this.props.value === newValue) { return; } this.props.onChangeText(newValue); } onOldInputKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if (this.lastKeyPressed && this.lastKeyPressed.length > key.length) { return; } this.lastKeyPressed = key; this.props.onKeyPress && this.props.onKeyPress(event); }; onOldInputBlur: () => void = () => { this.sendMessage(); }; onOldInputFocus: () => void = () => { // It's possible for the user to press the old input after the new one // appears. We can prevent that with pointerEvents="none", but that causes a // blur event when we set it, which makes the keyboard briefly pop down // before popping back up again when textInputRef is called below. Instead // we try to catch the focus event here and refocus the currentTextInput if (this.currentTextInput) { this.currentTextInput.focus(); } }; textInputRef: ( textInput: ?React.ElementRef, ) => void = textInput => { if (this.focused && textInput) { textInput.focus(); } this.currentTextInput = textInput; this.props.textInputRef(textInput); }; async getValueAndReset(): Promise { const { value } = this.props; this.props.onChangeText(''); if (!this.focused) { return value; } return await new Promise(resolve => { this.pendingMessage = { value, resolve }; this.setState(prevState => ({ textInputKey: prevState.textInputKey + 1, })); }); } onFocus: () => void = () => { this.focused = true; }; onBlur: () => void = () => { this.focused = false; if (this.pendingMessage) { // This is to catch a race condition where somebody hits the send button // and then blurs the TextInput before the textInputKey increment can // rerender this component. With this.focused set to false, the new // TextInput won't focus, and the old TextInput won't blur, which means // nothing will call sendMessage unless we do it right here. this.sendMessage(); } }; render(): React.Node { const { textInputRef, ...props } = this.props; const textInputs = []; if (this.state.textInputKey > 0) { textInputs.push( , ); } textInputs.push( , ); return {textInputs}; } } const styles = StyleSheet.create({ invisibleTextInput: { opacity: 0, position: 'absolute', }, textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/content-loading.react.js b/native/components/content-loading.react.js index 6c8faa296..9bdeb16ae 100644 --- a/native/components/content-loading.react.js +++ b/native/components/content-loading.react.js @@ -1,39 +1,39 @@ // @flow import * as React from 'react'; import { View, ActivityIndicator, StyleSheet } from 'react-native'; import type { Colors } from '../themes/colors'; type Props = { - fillType: 'flex' | 'absolute', - colors: Colors, + +fillType: 'flex' | 'absolute', + +colors: Colors, }; function ContentLoading(props: Props): React.Node { const viewStyle = props.fillType === 'flex' ? styles.fullFlex : styles.absoluteContainer; return ( ); } const styles = StyleSheet.create({ absoluteContainer: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, fullFlex: { flex: 1, }, }); export default ContentLoading; diff --git a/native/components/single-line.react.js b/native/components/single-line.react.js index c71be5ef7..6bfbb21ae 100644 --- a/native/components/single-line.react.js +++ b/native/components/single-line.react.js @@ -1,21 +1,21 @@ // @flow import * as React from 'react'; import { Text } from 'react-native'; import { firstLine } from 'lib/utils/string-utils'; type Props = { ...React.ElementConfig, - children: ?string, + +children: ?string, }; function SingleLine(props: Props): React.Node { const text = firstLine(props.children); return ( {text} ); } export { SingleLine }; diff --git a/native/error-boundary.react.js b/native/error-boundary.react.js index c6e46e0d9..435c418ed 100644 --- a/native/error-boundary.react.js +++ b/native/error-boundary.react.js @@ -1,57 +1,57 @@ // @flow import * as React from 'react'; import type { ErrorInfo, ErrorData } from 'lib/types/report-types'; import Crash from './crash.react'; let instance: ?ErrorBoundary = null; const defaultHandler = global.ErrorUtils.getGlobalHandler(); global.ErrorUtils.setGlobalHandler(error => { defaultHandler(error); if (instance) { instance.reportError(error); } }); type Props = { - children: React.Node, + +children: React.Node, }; type State = { - errorData: $ReadOnlyArray, + +errorData: $ReadOnlyArray, }; class ErrorBoundary extends React.PureComponent { state: State = { errorData: [], }; componentDidMount() { instance = this; } componentWillUnmount() { instance = null; } componentDidCatch(error: Error, info: ErrorInfo) { this.setState(prevState => ({ errorData: [...prevState.errorData, { error, info }], })); } reportError(error: Error) { this.setState(prevState => ({ errorData: [...prevState.errorData, { error }], })); } render(): React.Node { if (this.state.errorData.length > 0) { return ; } return this.props.children; } } export default ErrorBoundary; diff --git a/native/media/send-media-button.react.js b/native/media/send-media-button.react.js index 738f17974..dbee0ca8c 100644 --- a/native/media/send-media-button.react.js +++ b/native/media/send-media-button.react.js @@ -1,87 +1,87 @@ // @flow import * as React from 'react'; import { TouchableOpacity, View, Text, StyleSheet, Platform, Animated, } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import type { ViewStyle } from '../types/styles'; type Props = { ...React.ElementConfig, - onPress: () => mixed, - queueCount?: number, - containerStyle?: ViewStyle, - style?: ViewStyle, + +onPress: () => mixed, + +queueCount?: number, + +containerStyle?: ViewStyle, + +style?: ViewStyle, }; function SendMediaButton(props: Props): React.Node { const { onPress, queueCount, containerStyle, style, ...containerProps } = props; let queueCountText = null; if (queueCount !== undefined && queueCount !== null) { queueCountText = ( {queueCount} ); } return ( {queueCountText} ); } const styles = StyleSheet.create({ queueCountBubble: { alignItems: 'center', backgroundColor: '#222222', borderRadius: 25, height: 25, justifyContent: 'center', paddingBottom: Platform.OS === 'android' ? 2 : 0, paddingLeft: 1, position: 'absolute', right: -8, top: -8, width: 25, }, queueCountText: { color: 'white', textAlign: 'center', }, sendButton: { backgroundColor: '#7ED321', borderColor: 'white', borderRadius: 30, borderWidth: 4, paddingBottom: 16, paddingLeft: 14, paddingRight: 16, paddingTop: 14, }, sendIcon: { color: 'white', fontSize: 22, }, }); export default SendMediaButton; diff --git a/native/navigation/action-result-modal.react.js b/native/navigation/action-result-modal.react.js index 4c83a1291..cb96a9efd 100644 --- a/native/navigation/action-result-modal.react.js +++ b/native/navigation/action-result-modal.react.js @@ -1,80 +1,80 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSelector } from '../redux/redux-utils'; import { useOverlayStyles } from '../themes/colors'; import type { AppNavigationProp } from './app-navigator.react'; import { OverlayContext } from './overlay-context'; import type { NavigationRoute } from './route-names'; export type ActionResultModalParams = { message: string, preventPresses: true, }; type Props = { - navigation: AppNavigationProp<'ActionResultModal'>, - route: NavigationRoute<'ActionResultModal'>, + +navigation: AppNavigationProp<'ActionResultModal'>, + +route: NavigationRoute<'ActionResultModal'>, }; function ActionResultModal(props: Props): React.Node { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'ActionResultModal should have OverlayContext'); const { position } = overlayContext; // Timer resets whenever message updates const { goBackOnce } = props.navigation; const { message } = props.route.params; React.useEffect(() => { const timeoutID = setTimeout(goBackOnce, 2000); return () => clearTimeout(timeoutID); }, [message, goBackOnce]); const styles = useOverlayStyles(ourStyles); const bottomInset = useSelector(state => state.dimensions.bottomInset); const containerStyle = { ...styles.container, opacity: position, paddingBottom: bottomInset + 100, }; return ( {message} ); } const ourStyles = { backdrop: { backgroundColor: 'modalContrastBackground', bottom: 0, left: 0, opacity: 'modalContrastOpacity', position: 'absolute', right: 0, top: 0, }, container: { alignItems: 'center', flex: 1, justifyContent: 'flex-end', }, message: { borderRadius: 10, overflow: 'hidden', padding: 10, }, text: { color: 'modalContrastForegroundLabel', fontSize: 20, textAlign: 'center', }, }; export default ActionResultModal; diff --git a/native/navigation/header.react.js b/native/navigation/header.react.js index e65c0919d..fcf96a314 100644 --- a/native/navigation/header.react.js +++ b/native/navigation/header.react.js @@ -1,20 +1,20 @@ // @flow import { Header, type StackHeaderProps } from '@react-navigation/stack'; import * as React from 'react'; import DisconnectedBar from './disconnected-bar.react'; type Props = { ...StackHeaderProps, - activeTab: boolean, + +activeTab: boolean, }; export default function CustomHeader(props: Props): React.Node { const { activeTab, ...rest } = props; return ( <>
); } diff --git a/native/navigation/modal-pruner.react.js b/native/navigation/modal-pruner.react.js index 33246e392..f79504ffd 100644 --- a/native/navigation/modal-pruner.react.js +++ b/native/navigation/modal-pruner.react.js @@ -1,135 +1,135 @@ // @flow import type { PossiblyStaleNavigationState, PossiblyStaleRoute, } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { clearRootModalsActionType, clearOverlayModalsActionType, } from './action-types'; import type { NavContextType } from './navigation-context'; import { AppRouteName } from './route-names'; type DependencyInfo = { status: 'missing' | 'resolved' | 'unresolved', presenter: ?string, presenting: string[], parentRouteName: ?string, }; function collectDependencyInfo( route: PossiblyStaleNavigationState | PossiblyStaleRoute<>, dependencyMap?: Map = new Map(), parentRouteName?: ?string, ): Map { let state, routeName; if (route.name === undefined) { state = route; } else if (route.state) { ({ state, name: routeName } = route); } if (state) { for (const child of state.routes) { collectDependencyInfo(child, dependencyMap, routeName); } return dependencyMap; } if (!route.key) { return dependencyMap; } const { key } = route; const presenter = route.params && route.params.presentedFrom ? route.params.presentedFrom : null; invariant( presenter === null || typeof presenter === 'string', 'presentedFrom should be a string', ); let status = 'resolved'; if (presenter) { const presenterInfo = dependencyMap.get(presenter); if (!presenterInfo) { status = 'unresolved'; dependencyMap.set(presenter, { status: 'missing', presenter: undefined, presenting: [key], parentRouteName: undefined, }); } else if (presenterInfo) { status = presenterInfo.status; presenterInfo.presenting.push(key); } } const existingInfo = dependencyMap.get(key); const presenting = existingInfo ? existingInfo.presenting : []; dependencyMap.set(key, { status, presenter, presenting, parentRouteName, }); if (status === 'resolved') { const toResolve = [...presenting]; while (toResolve.length > 0) { const presentee = toResolve.pop(); const dependencyInfo = dependencyMap.get(presentee); invariant(dependencyInfo, 'could not find presentee'); dependencyInfo.status = 'resolved'; toResolve.push(...dependencyInfo.presenting); } } return dependencyMap; } type Props = { - navContext: NavContextType, + +navContext: NavContextType, }; function ModalPruner(props: Props): null { const { state, dispatch } = props.navContext; const [pruneRootModals, pruneOverlayModals] = React.useMemo(() => { const dependencyMap = collectDependencyInfo(state); const rootModals = [], overlayModals = []; for (const [key, info] of dependencyMap) { if (info.status !== 'unresolved') { continue; } if (!info.parentRouteName) { rootModals.push(key); } else if (info.parentRouteName === AppRouteName) { overlayModals.push(key); } } return [rootModals, overlayModals]; }, [state]); React.useEffect(() => { if (pruneRootModals.length > 0) { dispatch({ type: (clearRootModalsActionType: 'CLEAR_ROOT_MODALS'), payload: { keys: pruneRootModals }, }); } if (pruneOverlayModals.length > 0) { dispatch({ type: (clearOverlayModalsActionType: 'CLEAR_OVERLAY_MODALS'), payload: { keys: pruneOverlayModals }, }); } }, [dispatch, pruneRootModals, pruneOverlayModals]); return null; } export default ModalPruner; diff --git a/native/profile/custom-server-modal.react.js b/native/profile/custom-server-modal.react.js index 47875f431..8771f297f 100644 --- a/native/profile/custom-server-modal.react.js +++ b/native/profile/custom-server-modal.react.js @@ -1,137 +1,137 @@ // @flow import * as React from 'react'; import { Text } from 'react-native'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types'; import { setURLPrefix } from 'lib/utils/url-utils'; import Button from '../components/button.react'; import Modal from '../components/modal.react'; import TextInput from '../components/text-input.react'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; import { setCustomServer } from '../utils/url-utils'; export type CustomServerModalParams = { presentedFrom: string, }; type BaseProps = { +navigation: RootNavigationProp<'CustomServerModal'>, +route: NavigationRoute<'CustomServerModal'>, }; type Props = { ...BaseProps, +urlPrefix: string, +customServer: ?string, +styles: typeof unboundStyles, +dispatch: Dispatch, }; type State = { - customServer: string, + +customServer: string, }; class CustomServerModal extends React.PureComponent { constructor(props: Props) { super(props); const { customServer } = props; this.state = { customServer: customServer ? customServer : '', }; } render() { return ( ); } onChangeCustomServer = (newCustomServer: string) => { this.setState({ customServer: newCustomServer }); }; onPressGo = () => { const { customServer } = this.state; if (customServer !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: customServer, }); } if (customServer && customServer !== this.props.customServer) { this.props.dispatch({ type: setCustomServer, payload: customServer, }); } this.props.navigation.goBackOnce(); }; } const unboundStyles = { button: { backgroundColor: 'greenButton', borderRadius: 5, marginHorizontal: 2, marginVertical: 2, paddingHorizontal: 12, paddingVertical: 4, }, buttonText: { color: 'white', fontSize: 18, textAlign: 'center', }, container: { justifyContent: 'flex-end', }, modal: { flex: 0, flexDirection: 'row', }, textInput: { color: 'modalBackgroundLabel', flex: 1, fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; const ConnectedCustomServerModal: React.ComponentType = React.memo( function ConnectedCustomServerModal(props: BaseProps) { const urlPrefix = useSelector(state => state.urlPrefix); const customServer = useSelector(state => state.customServer); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }, ); export default ConnectedCustomServerModal; diff --git a/native/push/in-app-notif.react.js b/native/push/in-app-notif.react.js index 4d7bf8d25..2046de329 100644 --- a/native/push/in-app-notif.react.js +++ b/native/push/in-app-notif.react.js @@ -1,87 +1,87 @@ // @flow import * as React from 'react'; import { View, Text, StyleSheet, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { SingleLine } from '../components/single-line.react'; import type { GlobalTheme } from '../types/themes'; const edges = ['top']; type Props = { - title: ?string, - message: string, - activeTheme: ?GlobalTheme, + +title: ?string, + +message: string, + +activeTheme: ?GlobalTheme, }; function InAppNotif(props: Props): React.Node { const useLightStyle = Platform.OS === 'ios' && props.activeTheme !== 'dark'; let title = null; if (props.title) { const titleStyles = [ styles.title, useLightStyle ? styles.lightTitle : null, ]; title = ( <> {props.title} {'\n'} ); } const textStyles = [styles.text, useLightStyle ? styles.lightText : null]; const notificationContent = ( {title} {props.message} ); if (Platform.OS === 'android') { return ( {notificationContent} ); } return {notificationContent}; } const styles = StyleSheet.create({ lightText: { color: 'white', }, lightTitle: { color: 'white', }, notif: { alignItems: 'flex-start', alignSelf: 'flex-start', justifyContent: 'flex-start', width: '100%', }, text: { ...Platform.select({ ios: { fontSize: 16, marginTop: 16, marginBottom: 6, color: 'black', }, default: { fontSize: 18, marginVertical: 16, }, }), marginHorizontal: 10, }, title: { color: 'black', fontWeight: 'bold', }, }); export default InAppNotif; diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js index 38343dea8..52fdcc144 100644 --- a/web/calendar/calendar.react.js +++ b/web/calendar/calendar.react.js @@ -1,288 +1,288 @@ // @flow import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import dateFormat from 'dateformat'; import invariant from 'invariant'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { currentDaysToEntries } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { type EntryInfo, type CalendarQuery, type CalendarQueryUpdateResult, type CalendarQueryUpdateStartingPayload, } from 'lib/types/entry-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { getDate, dateString, startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { useSelector } from '../redux/redux-utils'; import { yearAssertingSelector, monthAssertingSelector, webCalendarQuery, } from '../selectors/nav-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import type { NavInfo } from '../types/nav-types'; import { canonicalURLFromReduxState } from '../url-utils'; import css from './calendar.css'; import Day from './day.react'; import FilterPanel from './filter-panel.react'; type BaseProps = { +url: string, }; type Props = { ...BaseProps, +year: number, +month: number, +daysToEntries: { +[dayString: string]: EntryInfo[] }, +navInfo: NavInfo, +currentCalendarQuery: () => CalendarQuery, +loggedIn: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; type State = { - filterPanelOpen: boolean, + +filterPanelOpen: boolean, }; class Calendar extends React.PureComponent { state: State = { filterPanelOpen: false, }; getDate( dayOfMonth: number, monthInput: ?number = undefined, yearInput: ?number = undefined, ) { return getDate( yearInput ? yearInput : this.props.year, monthInput ? monthInput : this.props.month, dayOfMonth, ); } prevMonthDates() { const { year, month } = this.props; const lastMonthDate = getDate(year, month - 1, 1); const prevYear = lastMonthDate.getFullYear(); const prevMonth = lastMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(prevYear, prevMonth), endDate: endDateForYearAndMonth(prevYear, prevMonth), }; } nextMonthDates() { const { year, month } = this.props; const nextMonthDate = getDate(year, month + 1, 1); const nextYear = nextMonthDate.getFullYear(); const nextMonth = nextMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(nextYear, nextMonth), endDate: endDateForYearAndMonth(nextYear, nextMonth), }; } render() { const { year, month } = this.props; const monthName = dateFormat(getDate(year, month, 1), 'mmmm'); const prevURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.prevMonthDates() }, this.props.url, this.props.loggedIn, ); const nextURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.nextMonthDates() }, this.props.url, this.props.loggedIn, ); const lastDayOfMonth = this.getDate(0, this.props.month + 1); const totalDaysInMonth = lastDayOfMonth.getDate(); const firstDayToPrint = 1 - this.getDate(1).getDay(); const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay(); const rows = []; let columns = []; let week = 1; let tabIndex = 1; for ( let curDayOfMonth = firstDayToPrint; curDayOfMonth <= lastDayToPrint; curDayOfMonth++ ) { if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) { columns.push(); } else { const dayString = dateString( this.props.year, this.props.month, curDayOfMonth, ); const entries = this.props.daysToEntries[dayString]; invariant( entries, 'the currentDaysToEntries selector should make sure all dayStrings ' + `in the current range have entries, but ${dayString} did not`, ); columns.push( , ); tabIndex += entries.length; } if (columns.length === 7) { rows.push({columns}); columns = []; } } let filterPanel = null; let calendarContentStyle = null; let filterButtonStyle = null; if (this.state.filterPanelOpen) { filterPanel = ; calendarContentStyle = { marginLeft: '300px' }; filterButtonStyle = { backgroundColor: 'rgba(0,0,0,0.67)' }; } return (
{filterPanel}
Filters
{rows}
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
); } toggleFilters = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ filterPanelOpen: !this.state.filterPanelOpen }); }; onClickPrevURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.prevMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; onClickNextURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.nextMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; } const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props) { const year = useSelector(yearAssertingSelector); const month = useSelector(monthAssertingSelector); const daysToEntries = useSelector(currentDaysToEntries); const navInfo = useSelector(state => state.navInfo); const currentCalendarQuery = useSelector(webCalendarQuery); const loggedIn = useSelector(isLoggedIn); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }, ); export default ConnectedCalendar; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 31e9bf468..242ba6c56 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,491 +1,491 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import * as React from 'react'; import { useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { useWatchThread, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { type InputState, InputStateContext } from '../input/input-state'; import LoadingIndicator from '../loading-indicator.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; import { useSelector } from '../redux/redux-utils'; import ChatInputBar from './chat-input-bar.react'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types'; import Message from './message.react'; import type { OnMessagePositionWithContainerInfo, MessagePositionInfo, } from './position-types'; import RelationshipPrompt from './relationship-prompt/relationship-prompt'; import ThreadTopBar from './thread-top-bar.react'; type PassedProps = { // Redux state +activeChatThreadID: ?string, +threadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +timeZone: ?string, +supportsReverseFlex: boolean, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withInputState +inputState: ?InputState, }; type ReactDnDProps = { - isActive: boolean, - connectDropTarget: (node: React.Node) => React.Node, + +isActive: boolean, + +connectDropTarget: (node: React.Node) => React.Node, }; type Props = { ...PassedProps, ...ReactDnDProps, }; type State = { +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; class ChatMessageList extends React.PureComponent { state: State = { mouseOverMessagePosition: null, }; container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; componentDidMount() { this.scrollToBottom(); } getSnapshotBeforeUpdate(prevProps: Props) { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props) { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; if ( this.loadingFromScroll && messageListData && (!prevMessageListData || messageListData.length > prevMessageListData.length || this.props.startReached) ) { this.loadingFromScroll = false; } const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (this.props.supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = item => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; setMouseOverMessagePosition = (messagePositionInfo: MessagePositionInfo) => { if (!this.messageContainer) { return; } if (messagePositionInfo.type === 'off') { this.setState({ mouseOverMessagePosition: null }); return; } const { top: containerTop, bottom: containerBottom, left: containerLeft, right: containerRight, height: containerHeight, width: containerWidth, } = this.messageContainer.getBoundingClientRect(); const mouseOverMessagePosition = { ...messagePositionInfo, messagePosition: { ...messagePositionInfo.messagePosition, top: messagePositionInfo.messagePosition.top - containerTop, bottom: messagePositionInfo.messagePosition.bottom - containerTop, left: messagePositionInfo.messagePosition.left - containerLeft, right: messagePositionInfo.messagePosition.right - containerLeft, }, containerPosition: { top: containerTop, bottom: containerBottom, left: containerLeft, right: containerRight, height: containerHeight, width: containerWidth, }, }; this.setState({ mouseOverMessagePosition }); }; render() { const { messageListData, threadInfo, inputState, connectDropTarget, isActive, } = this.props; if (!messageListData) { return
; } invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); const containerStyle = classNames({ [css.container]: true, [css.activeContainer]: isActive, }); let relationshipPrompt; if (this.props.threadInfo) { relationshipPrompt = ( ); } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); return connectDropTarget(
{relationshipPrompt}
{messages}
, ); } containerRef = (container: ?HTMLDivElement) => { if (container) { container.addEventListener('paste', this.onPaste); } this.container = container; }; onPaste = (e: ClipboardEvent) => { const { inputState } = this.props; if (!inputState) { return; } const { clipboardData } = e; if (!clipboardData) { return; } const { files } = clipboardData; if (files.length === 0) { return; } e.preventDefault(); inputState.appendFiles([...files]); }; messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } if (this.state.mouseOverMessagePosition) { this.setState({ mouseOverMessagePosition: null }); } this.possiblyLoadMoreMessages(); }; possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } } oldestMessageServerID(): ?string { const data = this.props.messageListData; invariant(data, 'should be set'); for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType<{}> = React.memo<{}>( function ConnectedChatMessageList(): React.Node { const userAgent = useSelector(state => state.userAgent); const supportsReverseFlex = React.useMemo(() => { const browser = detectBrowser(userAgent); return ( !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81 ); }, [userAgent]); const timeZone = useSelector(state => state.timeZone); const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const baseThreadInfo = useSelector(state => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } return threadInfoSelector(state)[activeID] ?? state.navInfo.pendingThread; }); const existingThreadInfoFinder = useExistingThreadInfoFinder( baseThreadInfo, ); const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: false, userInfoInputArray: [], }), [existingThreadInfoFinder], ); const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = useSelector(state => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } if (state.navInfo.pendingThread) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const inputState = React.useContext(InputStateContext); const [dndProps, connectDropTarget] = useDrop({ accept: NativeTypes.FILE, drop: item => { const { files } = item; if (inputState && files.length > 0) { inputState.appendFiles(files); } }, collect: monitor => ({ isActive: monitor.isOver() && monitor.canDrop(), }), }); const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo?.id); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); useWatchThread(threadInfo); return ( ); }, ); export default ConnectedChatMessageList;